/**
 * Copyright 2010-present Facebook.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.widget;

import android.content.Context;
import android.graphics.Bitmap;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.*;
import com.facebook.FacebookException;
import com.facebook.android.R;
import com.facebook.internal.ImageDownloader;
import com.facebook.internal.ImageRequest;
import com.facebook.internal.ImageResponse;
import com.facebook.model.GraphObject;
import org.json.JSONObject;

import java.net.URI;
import java.net.URISyntaxException;
import java.text.Collator;
import java.util.*;

class GraphObjectAdapter<T extends GraphObject> extends BaseAdapter implements SectionIndexer
{
  private static final int DISPLAY_SECTIONS_THRESHOLD = 1;
  private static final int HEADER_VIEW_TYPE = 0;
  private static final int GRAPH_OBJECT_VIEW_TYPE = 1;
  private static final int ACTIVITY_CIRCLE_VIEW_TYPE = 2;
  private static final int MAX_PREFETCHED_PICTURES = 20;

  private static final String ID = "id";
  private static final String NAME = "name";
  private static final String PICTURE = "picture";

  private final Map<String, ImageRequest> pendingRequests = new HashMap<String, ImageRequest>();
  private final LayoutInflater inflater;
  private List<String> sectionKeys = new ArrayList<String>();
  private Map<String, ArrayList<T>> graphObjectsBySection = new HashMap<String, ArrayList<T>>();
  private Map<String, T> graphObjectsById = new HashMap<String, T>();
  private boolean displaySections;
  private List<String> sortFields;
  private String groupByField;
  private boolean showPicture;
  private boolean showCheckbox;
  private Filter<T> filter;
  private DataNeededListener dataNeededListener;
  private GraphObjectCursor<T> cursor;
  private Context context;
  private Map<String, ImageResponse> prefetchedPictureCache = new HashMap<String, ImageResponse>();
  private ArrayList<String> prefetchedProfilePictureIds = new ArrayList<String>();
  private OnErrorListener onErrorListener;

  public interface DataNeededListener
  {
    public void onDataNeeded();
  }

  public interface OnErrorListener
  {
    void onError(GraphObjectAdapter<?> adapter, FacebookException error);
  }

  public static class SectionAndItem<T extends GraphObject>
  {
    public String sectionKey;
    public T graphObject;

    public enum Type
    {
      GRAPH_OBJECT,
      SECTION_HEADER,
      ACTIVITY_CIRCLE
    }

    public SectionAndItem(String sectionKey, T graphObject)
    {
      this.sectionKey = sectionKey;
      this.graphObject = graphObject;
    }

    public Type getType()
    {
      if (sectionKey == null)
      {
        return Type.ACTIVITY_CIRCLE;
      }
      else
        if (graphObject == null)
        {
          return Type.SECTION_HEADER;
        }
        else
        {
          return Type.GRAPH_OBJECT;
        }
    }
  }

  interface Filter<T>
  {
    boolean includeItem(T graphObject);
  }

  public GraphObjectAdapter(Context context)
  {
    this.context = context;
    this.inflater = LayoutInflater.from(context);
  }

  public List<String> getSortFields()
  {
    return sortFields;
  }

  public void setSortFields(List<String> sortFields)
  {
    this.sortFields = sortFields;
  }

  public String getGroupByField()
  {
    return groupByField;
  }

  public void setGroupByField(String groupByField)
  {
    this.groupByField = groupByField;
  }

  public boolean getShowPicture()
  {
    return showPicture;
  }

  public void setShowPicture(boolean showPicture)
  {
    this.showPicture = showPicture;
  }

  public boolean getShowCheckbox()
  {
    return showCheckbox;
  }

  public void setShowCheckbox(boolean showCheckbox)
  {
    this.showCheckbox = showCheckbox;
  }

  public DataNeededListener getDataNeededListener()
  {
    return dataNeededListener;
  }

  public void setDataNeededListener(DataNeededListener dataNeededListener)
  {
    this.dataNeededListener = dataNeededListener;
  }

  public OnErrorListener getOnErrorListener()
  {
    return onErrorListener;
  }

  public void setOnErrorListener(OnErrorListener onErrorListener)
  {
    this.onErrorListener = onErrorListener;
  }

  public GraphObjectCursor<T> getCursor()
  {
    return cursor;
  }

  public boolean changeCursor(GraphObjectCursor<T> cursor)
  {
    if (this.cursor == cursor)
    {
      return false;
    }
    if (this.cursor != null)
    {
      this.cursor.close();
    }
    this.cursor = cursor;

    rebuildAndNotify();
    return true;
  }

  public void rebuildAndNotify()
  {
    rebuildSections();
    notifyDataSetChanged();
  }

  public void prioritizeViewRange(int firstVisibleItem, int lastVisibleItem, int prefetchBuffer)
  {
    if ((lastVisibleItem < firstVisibleItem) || (sectionKeys.size() == 0))
    {
      return;
    }

    // We want to prioritize requests for items which are visible but do not have pictures
    // loaded yet. We also want to pre-fetch pictures for items which are not yet visible
    // but are within a buffer on either side of the visible items, on the assumption that
    // they will be visible soon. For these latter items, we'll store the images in memory
    // in the hopes we can immediately populate their image view when needed.

    // Prioritize the requests in reverse order since each call to prioritizeRequest will just
    // move it to the front of the queue. And we want the earliest ones in the range to be at
    // the front of the queue, so all else being equal, the list will appear to populate from
    // the top down.
    for (int i = lastVisibleItem; i >= 0; i--)
    {
      SectionAndItem<T> sectionAndItem = getSectionAndItem(i);
      if (sectionAndItem.graphObject != null)
      {
        String id = getIdOfGraphObject(sectionAndItem.graphObject);
        ImageRequest request = pendingRequests.get(id);
        if (request != null)
        {
          ImageDownloader.prioritizeRequest(request);
        }
      }
    }

    // For items which are not visible, but within the buffer on either side, we want to
    // fetch those items and store them in a small in-memory cache of bitmaps.
    int start = Math.max(0, firstVisibleItem - prefetchBuffer);
    int end = Math.min(lastVisibleItem + prefetchBuffer, getCount() - 1);
    ArrayList<T> graphObjectsToPrefetchPicturesFor = new ArrayList<T>();
    // Add the IDs before and after the visible range.
    for (int i = start; i < firstVisibleItem; ++i)
    {
      SectionAndItem<T> sectionAndItem = getSectionAndItem(i);
      if (sectionAndItem.graphObject != null)
      {
        graphObjectsToPrefetchPicturesFor.add(sectionAndItem.graphObject);
      }
    }
    for (int i = lastVisibleItem + 1; i <= end; ++i)
    {
      SectionAndItem<T> sectionAndItem = getSectionAndItem(i);
      if (sectionAndItem.graphObject != null)
      {
        graphObjectsToPrefetchPicturesFor.add(sectionAndItem.graphObject);
      }
    }
    for (T graphObject : graphObjectsToPrefetchPicturesFor)
    {
      URI uri = getPictureUriOfGraphObject(graphObject);
      final String id = getIdOfGraphObject(graphObject);

      // This URL already have been requested for pre-fetching, but we want to act in an LRU manner, so move
      // it to the end of the list regardless.
      boolean alreadyPrefetching = prefetchedProfilePictureIds.remove(id);
      prefetchedProfilePictureIds.add(id);

      // If we've already requested it for pre-fetching, no need to do so again.
      if (!alreadyPrefetching)
      {
        downloadProfilePicture(id, uri, null);
      }
    }
  }

  protected String getSectionKeyOfGraphObject(T graphObject)
  {
    String result = null;

    if (groupByField != null)
    {
      result = (String) graphObject.getProperty(groupByField);
      if (result != null && result.length() > 0)
      {
        result = result.substring(0, 1).toUpperCase();
      }
    }

    return (result != null) ? result : "";
  }

  protected CharSequence getTitleOfGraphObject(T graphObject)
  {
    return (String) graphObject.getProperty(NAME);
  }

  protected CharSequence getSubTitleOfGraphObject(T graphObject)
  {
    return null;
  }

  protected URI getPictureUriOfGraphObject(T graphObject)
  {
    String uri = null;
    Object o = graphObject.getProperty(PICTURE);
    if (o instanceof String)
    {
      uri = (String) o;
    }
    else
      if (o instanceof JSONObject)
      {
        ItemPicture itemPicture = GraphObject.Factory.create((JSONObject) o).cast(ItemPicture.class);
        ItemPictureData data = itemPicture.getData();
        if (data != null)
        {
          uri = data.getUrl();
        }
      }

    if (uri != null)
    {
      try
      {
        return new URI(uri);
      }
      catch (URISyntaxException e)
      {
      }
    }
    return null;
  }

  protected View getSectionHeaderView(String sectionHeader, View convertView, ViewGroup parent)
  {
    TextView result = (TextView) convertView;

    if (result == null)
    {
      result = (TextView) inflater.inflate(R.layout.com_facebook_picker_list_section_header, null);
    }

    result.setText(sectionHeader);

    return result;
  }

  protected View getGraphObjectView(T graphObject, View convertView, ViewGroup parent)
  {
    View result = convertView;

    if (result == null)
    {
      result = createGraphObjectView(graphObject);
    }

    populateGraphObjectView(result, graphObject);
    return result;
  }

  private View getActivityCircleView(View convertView, ViewGroup parent)
  {
    View result = convertView;

    if (result == null)
    {
      result = inflater.inflate(R.layout.com_facebook_picker_activity_circle_row, null);
    }
    ProgressBar activityCircle = (ProgressBar) result.findViewById(R.id.com_facebook_picker_row_activity_circle);
    activityCircle.setVisibility(View.VISIBLE);

    return result;
  }

  protected int getGraphObjectRowLayoutId(T graphObject)
  {
    return R.layout.com_facebook_picker_list_row;
  }

  protected int getDefaultPicture()
  {
    return R.drawable.com_facebook_profile_default_icon;
  }

  protected View createGraphObjectView(T graphObject)
  {
    View result = inflater.inflate(getGraphObjectRowLayoutId(graphObject), null);

    ViewStub checkboxStub = (ViewStub) result.findViewById(R.id.com_facebook_picker_checkbox_stub);
    if (checkboxStub != null)
    {
      if (!getShowCheckbox())
      {
        checkboxStub.setVisibility(View.GONE);
      }
      else
      {
        CheckBox checkBox = (CheckBox) checkboxStub.inflate();
        updateCheckboxState(checkBox, false);
      }
    }

    ViewStub profilePicStub = (ViewStub) result.findViewById(R.id.com_facebook_picker_profile_pic_stub);
    if (!getShowPicture())
    {
      profilePicStub.setVisibility(View.GONE);
    }
    else
    {
      ImageView imageView = (ImageView) profilePicStub.inflate();
      imageView.setVisibility(View.VISIBLE);
    }

    return result;
  }

  protected void populateGraphObjectView(View view, T graphObject)
  {
    String id = getIdOfGraphObject(graphObject);
    view.setTag(id);

    CharSequence title = getTitleOfGraphObject(graphObject);
    TextView titleView = (TextView) view.findViewById(R.id.com_facebook_picker_title);
    if (titleView != null)
    {
      titleView.setText(title, TextView.BufferType.SPANNABLE);
    }

    CharSequence subtitle = getSubTitleOfGraphObject(graphObject);
    TextView subtitleView = (TextView) view.findViewById(R.id.picker_subtitle);
    if (subtitleView != null)
    {
      if (subtitle != null)
      {
        subtitleView.setText(subtitle, TextView.BufferType.SPANNABLE);
        subtitleView.setVisibility(View.VISIBLE);
      }
      else
      {
        subtitleView.setVisibility(View.GONE);
      }
    }

    if (getShowCheckbox())
    {
      CheckBox checkBox = (CheckBox) view.findViewById(R.id.com_facebook_picker_checkbox);
      updateCheckboxState(checkBox, isGraphObjectSelected(id));
    }

    if (getShowPicture())
    {
      URI pictureURI = getPictureUriOfGraphObject(graphObject);

      if (pictureURI != null)
      {
        ImageView profilePic = (ImageView) view.findViewById(R.id.com_facebook_picker_image);

        // See if we have already pre-fetched this; if not, download it.
        if (prefetchedPictureCache.containsKey(id))
        {
          ImageResponse response = prefetchedPictureCache.get(id);
          profilePic.setImageBitmap(response.getBitmap());
          profilePic.setTag(response.getRequest().getImageUri());
        }
        else
        {
          downloadProfilePicture(id, pictureURI, profilePic);
        }
      }
    }
  }

  /**
   * @throws FacebookException if the GraphObject doesn't have an ID.
   */
  String getIdOfGraphObject(T graphObject)
  {
    if (graphObject.asMap().containsKey(ID))
    {
      Object obj = graphObject.getProperty(ID);
      if (obj instanceof String)
      {
        return (String) obj;
      }
    }
    throw new FacebookException("Received an object without an ID.");
  }

  boolean filterIncludesItem(T graphObject)
  {
    return filter == null || filter.includeItem(graphObject);
  }

  Filter<T> getFilter()
  {
    return filter;
  }

  void setFilter(Filter<T> filter)
  {
    this.filter = filter;
  }

  boolean isGraphObjectSelected(String graphObjectId)
  {
    return false;
  }

  void updateCheckboxState(CheckBox checkBox, boolean graphObjectSelected)
  {
    // Default is no-op
  }

  String getPictureFieldSpecifier()
  {
    // How big is our image?
    View view = createGraphObjectView(null);
    ImageView picture = (ImageView) view.findViewById(R.id.com_facebook_picker_image);
    if (picture == null)
    {
      return null;
    }

    // Note: these dimensions are in pixels, not dips
    ViewGroup.LayoutParams layoutParams = picture.getLayoutParams();
    return String.format("picture.height(%d).width(%d)", layoutParams.height, layoutParams.width);
  }


  private boolean shouldShowActivityCircleCell()
  {
    // We show the "more data" activity circle cell if we have a listener to request more data,
    // we are expecting more data, and we have some data already (i.e., not on a fresh query).
    return (cursor != null) && cursor.areMoreObjectsAvailable() && (dataNeededListener != null) && !isEmpty();
  }

  private void rebuildSections()
  {
    sectionKeys = new ArrayList<String>();
    graphObjectsBySection = new HashMap<String, ArrayList<T>>();
    graphObjectsById = new HashMap<String, T>();
    displaySections = false;

    if (cursor == null || cursor.getCount() == 0)
    {
      return;
    }

    int objectsAdded = 0;
    cursor.moveToFirst();
    do
    {
      T graphObject = cursor.getGraphObject();

      if (!filterIncludesItem(graphObject))
      {
        continue;
      }

      objectsAdded++;

      String sectionKeyOfItem = getSectionKeyOfGraphObject(graphObject);
      if (!graphObjectsBySection.containsKey(sectionKeyOfItem))
      {
        sectionKeys.add(sectionKeyOfItem);
        graphObjectsBySection.put(sectionKeyOfItem, new ArrayList<T>());
      }
      List<T> section = graphObjectsBySection.get(sectionKeyOfItem);
      section.add(graphObject);

      graphObjectsById.put(getIdOfGraphObject(graphObject), graphObject);
    } while (cursor.moveToNext());

    if (sortFields != null)
    {
      final Collator collator = Collator.getInstance();
      for (List<T> section : graphObjectsBySection.values())
      {
        Collections.sort(section, new Comparator<GraphObject>()
        {
          @Override
          public int compare(GraphObject a, GraphObject b)
          {
            return compareGraphObjects(a, b, sortFields, collator);
          }
        });
      }
    }

    Collections.sort(sectionKeys, Collator.getInstance());

    displaySections = sectionKeys.size() > 1 && objectsAdded > DISPLAY_SECTIONS_THRESHOLD;
  }

  SectionAndItem<T> getSectionAndItem(int position)
  {
    if (sectionKeys.size() == 0)
    {
      return null;
    }
    String sectionKey = null;
    T graphObject = null;

    if (!displaySections)
    {
      sectionKey = sectionKeys.get(0);
      List<T> section = graphObjectsBySection.get(sectionKey);
      if (position >= 0 && position < section.size())
      {
        graphObject = graphObjectsBySection.get(sectionKey).get(position);
      }
      else
      {
        // We are off the end; we must be adding an activity circle to indicate more data is coming.
        assert dataNeededListener != null && cursor.areMoreObjectsAvailable();
        // We return null for both to indicate this.
        return new SectionAndItem<T>(null, null);
      }
    }
    else
    {
      // Count through the sections; the "0" position in each section is the header. We decrement
      // position each time we skip forward a certain number of elements, including the header.
      for (String key : sectionKeys)
      {
        // Decrement if we skip over the header
        if (position-- == 0)
        {
          sectionKey = key;
          break;
        }

        List<T> section = graphObjectsBySection.get(key);
        if (position < section.size())
        {
          // The position is somewhere in this section. Get the corresponding graph object.
          sectionKey = key;
          graphObject = section.get(position);
          break;
        }
        // Decrement by as many items as we skipped over
        position -= section.size();
      }
    }
    if (sectionKey != null)
    {
      // Note: graphObject will be null if this represents a section header.
      return new SectionAndItem<T>(sectionKey, graphObject);
    }
    else
    {
      throw new IndexOutOfBoundsException("position");
    }
  }

  int getPosition(String sectionKey, T graphObject)
  {
    int position = 0;
    boolean found = false;

    // First find the section key and increment position one for each header we will render;
    // increment by the size of each section prior to the one we want.
    for (String key : sectionKeys)
    {
      if (displaySections)
      {
        position++;
      }
      if (key.equals(sectionKey))
      {
        found = true;
        break;
      }
      else
      {
        position += graphObjectsBySection.get(key).size();
      }
    }

    if (!found)
    {
      return -1;
    }
    else
      if (graphObject == null)
      {
        // null represents the header for a section; we counted this header in position earlier,
        // so subtract it back out.
        return position - (displaySections ? 1 : 0);
      }

    // Now find index of this item within that section.
    for (T t : graphObjectsBySection.get(sectionKey))
    {
      if (GraphObject.Factory.hasSameId(t, graphObject))
      {
        return position;
      }
      position++;
    }
    return -1;
  }

  @Override
  public boolean isEmpty()
  {
    // We'll never populate sectionKeys unless we have at least one object.
    return sectionKeys.size() == 0;
  }

  @Override
  public int getCount()
  {
    if (sectionKeys.size() == 0)
    {
      return 0;
    }

    // If we are not displaying sections, we don't display a header; otherwise, we have one header per item in
    // addition to the actual items.
    int count = (displaySections) ? sectionKeys.size() : 0;
    for (List<T> section : graphObjectsBySection.values())
    {
      count += section.size();
    }

    // If we should show a cell with an activity circle indicating more data is coming, add it to the count.
    if (shouldShowActivityCircleCell())
    {
      ++count;
    }

    return count;
  }

  @Override
  public boolean areAllItemsEnabled()
  {
    return displaySections;
  }

  @Override
  public boolean hasStableIds()
  {
    return true;
  }

  @Override
  public boolean isEnabled(int position)
  {
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);
    return sectionAndItem.getType() == SectionAndItem.Type.GRAPH_OBJECT;
  }

  @Override
  public Object getItem(int position)
  {
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);
    return (sectionAndItem.getType() == SectionAndItem.Type.GRAPH_OBJECT) ? sectionAndItem.graphObject : null;
  }

  @Override
  public long getItemId(int position)
  {
    // We assume IDs that can be converted to longs. If this is not the case for certain types of
    // GraphObjects, subclasses should override this to return, e.g., position, and override hasStableIds
    // to return false.
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);
    if (sectionAndItem != null && sectionAndItem.graphObject != null)
    {
      String id = getIdOfGraphObject(sectionAndItem.graphObject);
      if (id != null)
      {
        return Long.parseLong(id);
      }
    }
    return 0;
  }

  @Override
  public int getViewTypeCount()
  {
    return 3;
  }

  @Override
  public int getItemViewType(int position)
  {
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);
    switch (sectionAndItem.getType())
    {
      case SECTION_HEADER:
        return HEADER_VIEW_TYPE;
      case GRAPH_OBJECT:
        return GRAPH_OBJECT_VIEW_TYPE;
      case ACTIVITY_CIRCLE:
        return ACTIVITY_CIRCLE_VIEW_TYPE;
      default:
        throw new FacebookException("Unexpected type of section and item.");
    }
  }

  @Override
  public View getView(int position, View convertView, ViewGroup parent)
  {
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);

    switch (sectionAndItem.getType())
    {
      case SECTION_HEADER:
        return getSectionHeaderView(sectionAndItem.sectionKey, convertView, parent);
      case GRAPH_OBJECT:
        return getGraphObjectView(sectionAndItem.graphObject, convertView, parent);
      case ACTIVITY_CIRCLE:
        // If we get a request for this view, it means we need more data.
        assert cursor.areMoreObjectsAvailable() && (dataNeededListener != null);
        dataNeededListener.onDataNeeded();
        return getActivityCircleView(convertView, parent);
      default:
        throw new FacebookException("Unexpected type of section and item.");
    }
  }

  @Override
  public Object[] getSections()
  {
    if (displaySections)
    {
      return sectionKeys.toArray();
    }
    else
    {
      return new Object[0];
    }
  }

  @Override
  public int getPositionForSection(int section)
  {
    if (displaySections)
    {
      section = Math.max(0, Math.min(section, sectionKeys.size() - 1));
      if (section < sectionKeys.size())
      {
        return getPosition(sectionKeys.get(section), null);
      }
    }
    return 0;
  }

  @Override
  public int getSectionForPosition(int position)
  {
    SectionAndItem<T> sectionAndItem = getSectionAndItem(position);
    if (sectionAndItem != null &&
        sectionAndItem.getType() != SectionAndItem.Type.ACTIVITY_CIRCLE)
    {
      return Math.max(0, Math.min(sectionKeys.indexOf(sectionAndItem.sectionKey), sectionKeys.size() - 1));
    }
    return 0;
  }

  public List<T> getGraphObjectsById(Collection<String> ids)
  {
    Set<String> idSet = new HashSet<String>();
    idSet.addAll(ids);

    ArrayList<T> result = new ArrayList<T>(idSet.size());
    for (String id : idSet)
    {
      T graphObject = graphObjectsById.get(id);
      if (graphObject != null)
      {
        result.add(graphObject);
      }
    }

    return result;
  }

  private void downloadProfilePicture(final String profileId, URI pictureURI, final ImageView imageView)
  {
    if (pictureURI == null)
    {
      return;
    }

    // If we don't have an imageView, we are pre-fetching this image to store in-memory because we
    // think the user might scroll to its corresponding list row. If we do have an imageView, we
    // only want to queue a download if the view's tag isn't already set to the URL (which would mean
    // it's already got the correct picture).
    boolean prefetching = imageView == null;
    if (prefetching || !pictureURI.equals(imageView.getTag()))
    {
      if (!prefetching)
      {
        // Setting the tag to the profile ID indicates that we're currently downloading the
        // picture for this profile; we'll set it to the actual picture URL when complete.
        imageView.setTag(profileId);
        imageView.setImageResource(getDefaultPicture());
      }

      ImageRequest.Builder builder = new ImageRequest.Builder(context.getApplicationContext(), pictureURI)
          .setCallerTag(this)
          .setCallback(
              new ImageRequest.Callback()
              {
                @Override
                public void onCompleted(ImageResponse response)
                {
                  processImageResponse(response, profileId, imageView);
                }
              });

      ImageRequest newRequest = builder.build();
      pendingRequests.put(profileId, newRequest);

      ImageDownloader.downloadAsync(newRequest);
    }
  }

  private void callOnErrorListener(Exception exception)
  {
    if (onErrorListener != null)
    {
      if (!(exception instanceof FacebookException))
      {
        exception = new FacebookException(exception);
      }
      onErrorListener.onError(this, (FacebookException) exception);
    }
  }

  private void processImageResponse(ImageResponse response, String graphObjectId, ImageView imageView)
  {
    pendingRequests.remove(graphObjectId);
    if (response.getError() != null)
    {
      callOnErrorListener(response.getError());
    }

    if (imageView == null)
    {
      // This was a pre-fetch request.
      if (response.getBitmap() != null)
      {
        // Is the cache too big?
        if (prefetchedPictureCache.size() >= MAX_PREFETCHED_PICTURES)
        {
          // Find the oldest one and remove it.
          String oldestId = prefetchedProfilePictureIds.remove(0);
          prefetchedPictureCache.remove(oldestId);
        }
        prefetchedPictureCache.put(graphObjectId, response);
      }
    }
    else
      if (graphObjectId.equals(imageView.getTag()))
      {
        Exception error = response.getError();
        Bitmap bitmap = response.getBitmap();
        if (error == null && bitmap != null)
        {
          imageView.setImageBitmap(bitmap);
          imageView.setTag(response.getRequest().getImageUri());
        }
      }
  }

  private static int compareGraphObjects(GraphObject a, GraphObject b, Collection<String> sortFields,
                                         Collator collator)
  {
    for (String sortField : sortFields)
    {
      String sa = (String) a.getProperty(sortField);
      String sb = (String) b.getProperty(sortField);

      if (sa != null && sb != null)
      {
        int result = collator.compare(sa, sb);
        if (result != 0)
        {
          return result;
        }
      }
      else
        if (!(sa == null && sb == null))
        {
          return (sa == null) ? -1 : 1;
        }
    }
    return 0;
  }


  // Graph object type to navigate the JSON that sometimes comes back instead of a URL string
  private interface ItemPicture extends GraphObject
  {
    ItemPictureData getData();
  }

  // Graph object type to navigate the JSON that sometimes comes back instead of a URL string
  private interface ItemPictureData extends GraphObject
  {
    String getUrl();
  }
}
